Micron Document
๐ŸŽ–๏ธGitะฏั€ะฐ๐ŸŽ–๏ธ

Commit 2d63db8dd40a1dbb515c453a1197bab637f7b4d2


Parents : 6412c51
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-27T11:35:32-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-27T16:35:32Z

fix(mqtt): scope user-CA trust to the MQTT connection (#6464)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/androidApp/src/main/res/xml/network_security_config.xml b/androidApp/src/main/res/xml/network_security_config.xml
index 11e036e0ab..c2c070ffc0 100644
--- a/androidApp/src/main/res/xml/network_security_config.xml
+++ b/androidApp/src/main/res/xml/network_security_config.xml
@@ -2,8 +2,12 @@
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
+ <!--
+ System anchors only. User-installed CAs used to be trusted here for self-hosted MQTT brokers
+ (#5365), but base-config applies to every TLS connection the app makes. mqtt-client 0.7.0 lets
+ that trust be scoped to the MQTT transports instead โ€” see mqttTlsConfig() in :core:network.
+ -->
<certificates src="system"/>
- <certificates src="user"/>
</trust-anchors>
</base-config>

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
index 26c51939d9..68e6b9fa43 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
@@ -34,6 +34,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.model.MqttConnectionState
import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.network.repository.mqttTlsConfig
import org.meshtastic.core.network.repository.resolveEndpoint
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeRepository
@@ -139,8 +140,10 @@ class MqttManagerImpl(
val endpoint = resolveEndpoint(address, tlsEnabled)
val result =
MqttClient.probe(endpoint = endpoint) {
- // probe() requires a transportFactory in 0.4.0 (errors otherwise); mirror the live client.
- transportFactory = TcpTransportFactory() + WebSocketTransportFactory()
+ // probe() requires a transportFactory in 0.4.0 (errors otherwise); mirror the live client,
+ // including its scoped private-CA trust hook โ€” otherwise a probe would fail where a connect succeeds.
+ val tls = mqttTlsConfig()
+ transportFactory = TcpTransportFactory(tls) + WebSocketTransportFactory(tls)
// Per-connection random suffix: myId identifies the node (and is null โ†’
// "unknown" before the node record loads), so two probes can collide on one
// client-id and evict each other (SESSION_TAKEN_OVER). See MQTTRepositoryImpl.

diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.android.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.android.kt
new file mode 100644
index 0000000000..7ec3ea6b93
--- /dev/null
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.android.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.repository
+
+import co.touchlab.kermit.Logger
+import io.ktor.network.tls.TLSConfigBuilder
+import java.security.KeyStore
+import javax.net.ssl.TrustManagerFactory
+import javax.net.ssl.X509TrustManager
+
+/**
+ * `AndroidCAStore` exposes the system CAs *and* the CAs the user has installed from Settings. Anchoring MQTT on it
+ * reproduces what `<certificates src="system"/>` + `<certificates src="user"/>` used to give the whole app, but only
+ * for the MQTT socket.
+ */
+private const val ANDROID_CA_STORE = "AndroidCAStore"
+
+actual fun mqttTlsConfig(): (TLSConfigBuilder.() -> Unit)? {
+ val manager = userCaTrustManager() ?: return null
+ return { trustManager = manager }
+}
+
+/**
+ * Built through a [TrustManagerFactory] so the result is one `X509TrustManagerExtensions` can wrap โ€” the transport
+ * needs that to reach the hostname-aware `checkServerTrusted(chain, authType, host)` overload the platform requires
+ * whenever `network_security_config.xml` carries any domain-specific config (ours does, for the localhost cleartext
+ * exemptions).
+ *
+ * Returning `null` on failure falls back to the platform default, which is the safe direction: a private-CA broker
+ * stops connecting, rather than trust silently widening.
+ */
+private fun userCaTrustManager(): X509TrustManager? = runCatching {
+ val keyStore = KeyStore.getInstance(ANDROID_CA_STORE).apply { load(null) }
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
+ .apply { init(keyStore) }
+ .trustManagers
+ .filterIsInstance<X509TrustManager>()
+ .firstOrNull()
+}
+ .onFailure {
+ Logger.w(throwable = it) { "Could not build the MQTT user-CA trust manager; using platform trust" }
+ }
+ .getOrNull()

diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt
index 95d0c72610..ada456a58e 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt
@@ -351,7 +351,9 @@ private fun defaultMqttClientFactory(setup: MqttClientSetup): MqttClientSession
MqttClient(setup.ownerId) {
// mqtt-client 0.4.0 makes transport a required SPI: the client throws at connect if unset.
// Register TCP/TLS (the default) + WebSocket (for user-entered ws://-/wss:// brokers).
- transportFactory = TcpTransportFactory() + WebSocketTransportFactory()
+ // Both get the same private-CA trust hook so the grant stays scoped to this socket โ€” see [mqttTlsConfig].
+ val tls = mqttTlsConfig()
+ transportFactory = TcpTransportFactory(tls) + WebSocketTransportFactory(tls)
keepAliveSeconds = MQTT_KEEPALIVE_SECONDS
autoReconnect = true
username = setup.mqttConfig?.username

diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.kt
new file mode 100644
index 0000000000..fc3f314528
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.repository
+
+import io.ktor.network.tls.TLSConfigBuilder
+
+/**
+ * TLS customisation applied to the MQTT transports, or `null` to keep the platform default trust decision.
+ *
+ * Self-hosted brokers are commonly fronted by a private or self-signed CA that the user installs into the device
+ * credential store. Android used to grant that trust app-wide via `<certificates src="user"/>` in `base-config` of
+ * `network_security_config.xml`, which widened every TLS connection the app makes โ€” map tiles, API calls, everything.
+ * mqtt-client 0.6.0/0.7.0 added a per-transport hook (`TcpTransportFactory`/`WebSocketTransportFactory`), so the grant
+ * now applies to the MQTT socket alone.
+ *
+ * Supplying a trust manager *replaces* the platform trust decision for these connections: network-security-config
+ * anchors, pinning, and Certificate Transparency policy no longer apply to them. That is a deliberately narrower blast
+ * radius than the app-wide anchor it replaces.
+ */
+expect fun mqttTlsConfig(): (TLSConfigBuilder.() -> Unit)?

diff --git a/core/network/src/iosMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.ios.kt b/core/network/src/iosMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.ios.kt
new file mode 100644
index 0000000000..1026377e8f
--- /dev/null
+++ b/core/network/src/iosMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.ios.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.repository
+
+import io.ktor.network.tls.TLSConfigBuilder
+
+/**
+ * Apple's `TLSConfigBuilder` actual has no `trustManager`, so there is nothing to configure here. On iOS a private CA
+ * is trusted by installing and enabling the profile system-wide instead.
+ */
+actual fun mqttTlsConfig(): (TLSConfigBuilder.() -> Unit)? = null

diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.jvm.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.jvm.kt
new file mode 100644
index 0000000000..97d3bd8c60
--- /dev/null
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/MqttTlsTrust.jvm.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.repository
+
+import io.ktor.network.tls.TLSConfigBuilder
+
+/**
+ * Desktop has no equivalent of Android's user credential store: the JVM already trusts whatever the running JDK's
+ * `cacerts` holds, and a self-hosted CA is added there (or via `-Djavax.net.ssl.trustStore`) rather than by the app.
+ * Use the platform trust decision unchanged.
+ */
+actual fun mqttTlsConfig(): (TLSConfigBuilder.() -> Unit)? = null

Served by rngit 1.5.4 - Generated in 0.07s